In this project, you will apply unsupervised learning techniques to identify segments of the population that form the core customer base for a mail-order sales company in Germany. These segments can then be used to direct marketing campaigns towards audiences that will have the highest expected rate of returns. The data that you will use has been provided by our partners at Bertelsmann Arvato Analytics, and represents a real-life data science task.
This notebook will help you complete this task by providing a framework within which you will perform your analysis steps. In each step of the project, you will see some text describing the subtask that you will perform, followed by one or more code cells for you to complete your work. Feel free to add additional code and markdown cells as you go along so that you can explore everything in precise chunks. The code cells provided in the base template will outline only the major tasks, and will usually not be enough to cover all of the minor tasks that comprise it.
It should be noted that while there will be precise guidelines on how you should handle certain tasks in the project, there will also be places where an exact specification is not provided. There will be times in the project where you will need to make and justify your own decisions on how to treat the data. These are places where there may not be only one way to handle the data. In real-life tasks, there may be many valid ways to approach an analysis task. One of the most important things you can do is clearly document your approach so that other scientists can understand the decisions you've made.
At the end of most sections, there will be a Markdown cell labeled Discussion. In these cells, you will report your findings for the completed section, as well as document the decisions that you made in your approach to each subtask. Your project will be evaluated not just on the code used to complete the tasks outlined, but also your communication about your observations and conclusions at each stage.
# import libraries here; add more as necessary
import numpy as np
import pandas as pd
import matplotlib as mpl
import matplotlib.pyplot as plt
import seaborn as sns
import missingno as msno
from sklearn import preprocessing, decomposition
from sklearn.impute import SimpleImputer
from sklearn.decomposition import PCA
from sklearn.cluster import KMeans
from mpl_toolkits.mplot3d import Axes3D
# magic word for producing visualizations in notebook
%matplotlib inline
%config InlineBackend.figure_format = 'retina'
pd.set_option('display.max_columns', 100)
pd.set_option('display.max_rows', 100)
%load_ext autoreload
%autoreload 2
# Plot styles
plt.style.use('fivethirtyeight')
plt.style.use('seaborn-poster')
There are four files associated with this project (not including this one):
Udacity_AZDIAS_Subset.csv: Demographics data for the general population of Germany; 891211 persons (rows) x 85 features (columns).Udacity_CUSTOMERS_Subset.csv: Demographics data for customers of a mail-order company; 191652 persons (rows) x 85 features (columns).Data_Dictionary.md: Detailed information file about the features in the provided datasets.AZDIAS_Feature_Summary.csv: Summary of feature attributes for demographics data; 85 features (rows) x 4 columnsEach row of the demographics files represents a single person, but also includes information outside of individuals, including information about their household, building, and neighborhood. You will use this information to cluster the general population into groups with similar demographic properties. Then, you will see how the people in the customers dataset fit into those created clusters. The hope here is that certain clusters are over-represented in the customers data, as compared to the general population; those over-represented clusters will be assumed to be part of the core userbase. This information can then be used for further applications, such as targeting for a marketing campaign.
To start off with, load in the demographics data for the general population into a pandas DataFrame, and do the same for the feature attributes summary. Note for all of the .csv data files in this project: they're semicolon (;) delimited, so you'll need an additional argument in your read_csv() call to read in the data properly. Also, considering the size of the main dataset, it may take some time for it to load completely.
Once the dataset is loaded, it's recommended that you take a little bit of time just browsing the general structure of the dataset and feature summary file. You'll be getting deep into the innards of the cleaning in the first major step of the project, so gaining some general familiarity can help you get your bearings.
# NOTE: the terms of use prevent me from including these files in the repo
# Load in the general demographics data.
azdias = pd.read_csv('Udacity_AZDIAS_Subset.csv', sep=';')
# Load in the feature summary file.
feat_info = pd.read_csv('AZDIAS_Feature_Summary.csv', sep=';')
# Check the structure of the data after it's loaded (e.g. print the number of
# rows and columns, print the first few rows).
print('azdias: ', azdias.shape)
print('feat_info: ', feat_info.shape)
azdias.head()
feat_info
feat_info.info()
The feature summary file contains a summary of properties for each demographics data column. You will use this file to help you make cleaning decisions during this stage of the project. First of all, you should assess the demographics data in terms of missing data. Pay attention to the following points as you perform your analysis, and take notes on what you observe. Make sure that you fill in the Discussion cell with your findings and decisions at the end of each step that has one!
The fourth column of the feature attributes summary (loaded in above as feat_info) documents the codes from the data dictionary that indicate missing or unknown data. While the file encodes this as a list (e.g. [-1,0]), this will get read in as a string object. You'll need to do a little bit of parsing to make use of it to identify and clean the data. Convert data that matches a 'missing' or 'unknown' value code into a numpy NaN value. You might want to see how much data takes on a 'missing' or 'unknown' code, and how much data is naturally missing, as a point of interest.
As one more reminder, you are encouraged to add additional cells to break up your analysis into manageable chunks.
df_clean = azdias.copy()
df_clean.dtypes
feat_info[feat_info.missing_or_unknown.str.contains('X')]
# Test one of the columns, we see string values here
df_clean.CAMEO_DEUG_2015.unique()
# Identify missing or unknown data values and convert them to NaNs.
df_clean.loc[df_clean['CAMEO_DEUG_2015'] == 'X'] = np.nan
df_clean.loc[df_clean['CAMEO_DEU_2015'] == 'XX'] = np.nan
df_clean.loc[df_clean['CAMEO_INTL_2015'] == 'XX'] = np.nan
# Now convert two of the columns to numeric since strings are gone, keep _DEU_ as string
df_clean['CAMEO_DEUG_2015'] = pd.to_numeric(df_clean['CAMEO_DEUG_2015'], errors='coerce')
df_clean['CAMEO_INTL_2015'] = pd.to_numeric(df_clean['CAMEO_INTL_2015'], errors='coerce')
# Let's retest that column, expect to see integers!
df_clean.CAMEO_DEUG_2015.unique()
# Iterate over the feat_info df - this is not the most efficient
# way to do it, and I'd like to try to handle the string values
# in one single for-loop as an improvement
for index, row in feat_info.iterrows():
attr = row['attribute']
# Strip out the brackets and separate on comma
values = row['missing_or_unknown'].replace('[', '').replace(']',
'').split(',')
for val in values:
# We still have strings in here (X / XX) so ignore them
if val not in ['', 'X', 'XX']:
val = int(val)
df_clean.loc[df_clean[attr] == val, attr] = np.nan
How much missing data is present in each column? There are a few columns that are outliers in terms of the proportion of values that are missing. You will want to use matplotlib's hist() function to visualize the distribution of missing value counts to find these columns. Identify and document these columns. While some of these columns might have justifications for keeping or re-encoding the data, for this project you should just remove them from the dataframe. (Feel free to make remarks about these outlier columns in the discussion, however!)
For the remaining features, are there any patterns in which columns have, or share, missing data?
# Perform an assessment of how much missing data there is in each column of the
# dataset.
# Thanks StackOverflow: https://stackoverflow.com/a/51071037
percent_missing = df_clean.isnull().sum() * 100 / len(df_clean)
missing_values = pd.DataFrame({
'column_name': df_clean.columns,
'percent_missing': percent_missing
})
# Investigate patterns in the amount of missing data in each column.
plt.hist(missing_values['percent_missing'], bins=20)
plt.show()
missing_values.sort_values(by='percent_missing', ascending=False).head(25)
Several of our columns have >50% missing values. Looking at them, they are:
TITEL_KZ Academic title flagAGER_TYP Best-ager typologyKK_KUNDENTYP Consumer pattern over past 12 monthsKBA05_BAUMAX Most common building type within the microcellGEBURTSJAHR Year of birthLet's drop all of these columns.
# Remove the outlier columns from the dataset. (You'll perform other data
# engineering tasks such as re-encoding and imputation later.)
df_clean = df_clean.drop(
columns=['TITEL_KZ', 'AGER_TYP', 'KK_KUNDENTYP', 'KBA05_BAUMAX', 'GEBURTSJAHR'])
I decided to remove any column that didn't have at least 50% coverage. The columns I removed were:
TITEL_KZ Academic title flagAGER_TYP Best-ager typologyKK_KUNDENTYP Consumer pattern over past 12 monthsKBA05_BAUMAX Most common building type within the microcellGEBURTSJAHR Year of birthSome of them seem like they would have been useful (such as consumer pattern and year of birth), but potentially the present values could be biased. For example, it's possible older respondents reported their year of birth but younger respondents did not. With >50% missing values, it's best to just remove the data from analysis.
Now, you'll perform a similar assessment for the rows of the dataset. How much data is missing in each row? As with the columns, you should see some groups of points that have a very different numbers of missing values. Divide the data into two subsets: one for data points that are above some threshold for missing values, and a second subset for points below that threshold.
In order to know what to do with the outlier rows, we should see if the distribution of data values on columns that are not missing data (or are missing very little data) are similar or different between the two groups. Select at least five of these columns and compare the distribution of values.
countplot() function to create a bar chart of code frequencies and matplotlib's subplot() function to put bar charts for the two subplots side by side.Depending on what you observe in your comparison, this will have implications on how you approach your conclusions later in the analysis. If the distributions of non-missing features look similar between the data with many missing values and the data with few or no missing values, then we could argue that simply dropping those points from the analysis won't present a major issue. On the other hand, if the data with many missing values looks very different from the data with few or no missing values, then we should make a note on those data as special. We'll revisit these data later on. Either way, you should continue your analysis for now using just the subset of the data with few or no missing values.
# How much data is missing in each row of the dataset?
msno.matrix(df_clean.sample(100), figsize=(20, 15), labels=True, fontsize=10)
plt.show()
msno.bar(df_clean, figsize=(20, 8), fontsize=12)
plt.show()
From my visual evaluation I can see that the rows with missing data tend to have a pattern where they're missing all of the household and building-level features. Most of these rows seem to be missing HEALTH_TYP. The easiest way to do this is to test if HEALTH_TYP is null, that column will be missing in all of the cases we're testing for.
# Create a new dataframe that contains only the "complete" rows
df_subset = df_clean.query('HEALTH_TYP != "NaN"')
# Create another dataframe that contains only the "incomplete" rows
df_incomplete = df_clean.query('HEALTH_TYP == "NaN"')
# Test our missing distribution again to make sure the rows look mostly complete
msno.bar(df_subset, figsize=(20, 8), fontsize=12)
plt.show()
# Compare the distribution of values for at least five columns where there are
# no or few missing values, between the two subsets.
df1 = pd.DataFrame(df_subset,
columns=[
'LP_LEBENSPHASE_FEIN', 'PRAEGENDE_JUGENDJAHRE',
'SEMIO_VERT', 'ALTERSKATEGORIE_GROB', 'LP_STATUS_GROB',
'ONLINE_AFFINITAET'
]).assign(group='complete')
df2 = pd.DataFrame(df_incomplete,
columns=[
'LP_LEBENSPHASE_FEIN', 'PRAEGENDE_JUGENDJAHRE',
'SEMIO_VERT', 'ALTERSKATEGORIE_GROB', 'LP_STATUS_GROB',
'ONLINE_AFFINITAET'
]).assign(group='missing')
cdf = pd.concat([df1, df2])
mdf = pd.melt(cdf, id_vars=['group'], var_name=['column'])
ax = sns.boxplot(x="group", y="value", hue="column", data=mdf)
plt.show()
There doesn't appear to be a significant difference between the two subsets on the columns I investigated. For that reason I'm going to proceed with dropping all rows that are missing HEALTH_TYP.
Checking for missing data isn't the only way in which you can prepare a dataset for analysis. Since the unsupervised learning techniques to be used will only work on data that is encoded numerically, you need to make a few encoding changes or additional assumptions to be able to make progress. In addition, while almost all of the values in the dataset are encoded using numbers, not all of them represent numeric values. Check the third column of the feature summary (feat_info) for a summary of types of measurement.
In the first two parts of this sub-step, you will perform an investigation of the categorical and mixed-type features and make a decision on each of them, whether you will keep, drop, or re-encode each. Then, in the last part, you will create a new data frame with only the selected and engineered columns.
Data wrangling is often the trickiest part of the data analysis process, and there's a lot of it to be done here. But stick with it: once you're done with this step, you'll be ready to get to the machine learning parts of the project!
# How many features are there of each data type?
feat_info.groupby('type')['attribute'].value_counts()
feat_info.query('type == "categorical"')['attribute'].to_list()
For categorical data, you would ordinarily need to encode the levels as dummy variables. Depending on the number of categories, perform one of the following:
# Assess categorical variables: which are binary, which are multi-level, and
# which one needs to be re-encoded?
# Re-encode categorical variable(s) to be kept in the analysis.
# One-hot encode the multi-level categoricals and string columns
df_subset = pd.get_dummies(
df_subset,
columns=[
'ANREDE_KZ', 'CJT_GESAMTTYP', 'FINANZTYP',
'GFK_URLAUBERTYP', 'GREEN_AVANTGARDE', 'LP_FAMILIE_FEIN',
'LP_FAMILIE_GROB', 'LP_STATUS_FEIN', 'LP_STATUS_GROB',
'NATIONALITAET_KZ', 'SHOPPER_TYP', 'SOHO_KZ', 'VERS_TYP',
'ZABEOTYP', 'GEBAEUDETYP', 'OST_WEST_KZ',
'CAMEO_DEUG_2015', 'CAMEO_DEU_2015'
],
prefix_sep='_')
df_subset.head()
df_subset.shape
I took all of the categorical columns and one-hot encoded them. I considered transforming the one binary variable OST_WEST_KZ but realized it would transform quite nicely into two one-hot encoded variables. I decided not to drop any columns at this stage.
There are a handful of features that are marked as "mixed" in the feature summary that require special treatment in order to be included in the analysis. There are two in particular that deserve attention; the handling of the rest are up to your own choices:
Be sure to check Data_Dictionary.md for the details needed to finish these tasks.
1.18. PRAEGENDE_JUGENDJAHRE
Dominating movement of person's youth (avantgarde vs. mainstream; east vs. west)
-1: unknown
0: unknown
1: 40s - war years (Mainstream, E+W)
2: 40s - reconstruction years (Avantgarde, E+W)
3: 50s - economic miracle (Mainstream, E+W)
4: 50s - milk bar / Individualisation (Avantgarde, E+W)
5: 60s - economic miracle (Mainstream, E+W)
6: 60s - generation 68 / student protestors (Avantgarde, W)
7: 60s - opponents to the building of the Wall (Avantgarde, E)
8: 70s - family orientation (Mainstream, E+W)
9: 70s - peace movement (Avantgarde, E+W)
10: 80s - Generation Golf (Mainstream, W)
11: 80s - ecological awareness (Avantgarde, W)
12: 80s - FDJ / communist party youth organisation (Mainstream, E)
13: 80s - Swords into ploughshares (Avantgarde, E)
14: 90s - digital media kids (Mainstream, E+W)
15: 90s - ecological awareness (Avantgarde, E+W)
# Investigate "PRAEGENDE_JUGENDJAHRE" and engineer two new variables
# Create new one-hot encoded columns
df_subset['DECADE_40s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 1 and x <= 2 else 0)
df_subset['DECADE_50s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 3 and x <= 4 else 0)
df_subset['DECADE_60s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 5 and x <= 7 else 0)
df_subset['DECADE_70s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 8 and x <= 9 else 0)
df_subset['DECADE_80s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 10 and x <= 13 else 0)
df_subset['DECADE_90s'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x >= 14 and x <= 15 else 0)
# Create new one-hot encoded movement columns
df_subset['MOVEMENT_MAINSTREAM'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x in [1, 3, 5, 8, 10, 12, 14] else 0)
df_subset['MOVEMENT_AVANTGARDE'] = df_subset['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x in [2, 4, 6, 7, 9, 11, 13, 15] else 0)
# Drop the original column
df_subset = df_subset.drop(columns=['PRAEGENDE_JUGENDJAHRE'])
# Test a sample of rows
df_subset.sample(10)
4.3. CAMEO_INTL_2015
German CAMEO: Wealth / Life Stage Typology, mapped to international code
-1: unknown
11: Wealthy Households - Pre-Family Couples & Singles
12: Wealthy Households - Young Couples With Children
13: Wealthy Households - Families With School Age Children
14: Wealthy Households - Older Families & Mature Couples
15: Wealthy Households - Elders In Retirement
21: Prosperous Households - Pre-Family Couples & Singles
22: Prosperous Households - Young Couples With Children
23: Prosperous Households - Families With School Age Children
24: Prosperous Households - Older Families & Mature Couples
25: Prosperous Households - Elders In Retirement
31: Comfortable Households - Pre-Family Couples & Singles
32: Comfortable Households - Young Couples With Children
33: Comfortable Households - Families With School Age Children
34: Comfortable Households - Older Families & Mature Couples
35: Comfortable Households - Elders In Retirement
41: Less Affluent Households - Pre-Family Couples & Singles
42: Less Affluent Households - Young Couples With Children
43: Less Affluent Households - Families With School Age Children
44: Less Affluent Households - Older Families & Mature Couples
45: Less Affluent Households - Elders In Retirement
51: Poorer Households - Pre-Family Couples & Singles
52: Poorer Households - Young Couples With Children
53: Poorer Households - Families With School Age Children
54: Poorer Households - Older Families & Mature Couples
55: Poorer Households - Elders In Retirement
XX: unknown
# Investigate "CAMEO_INTL_2015" and engineer two new variables.
# Split into two columns with 10s and 1s values
df_subset['CAMEO_WEALTH'], df_subset['CAMEO_LIFESTAGE'] = divmod(
pd.to_numeric(df_subset['CAMEO_INTL_2015'], downcast='integer'), 10)
# Drop the original column
df_subset = df_subset.drop(columns=['CAMEO_INTL_2015'])
# And then one-hot encode the new columns
# One-hot encode the multi-level categoricals and string columns
df_subset = pd.get_dummies(
df_subset,
columns=[
'CAMEO_WEALTH', 'CAMEO_LIFESTAGE'
],
prefix_sep='_')
I converted PRAEGENDE_JUGENDJAHRE into two new one-hot encoded features that capture decade and movement. I then split CAMEO_INTL_2015 into two new one-hot encoded features using the first digit (wealth) and the second digit (lifestage).
df_subset.dtypes
In order to finish this step up, you need to make sure that your data frame now only has the columns that you want to keep. To summarize, the dataframe should consist of the following:
Make sure that for any new columns that you have engineered, that you've excluded the original columns from the final dataset. Otherwise, their values will interfere with the analysis later on the project. For example, you should not keep "PRAEGENDE_JUGENDJAHRE", since its values won't be useful for the algorithm: only the values derived from it in the engineered features you created should be retained. As a reminder, your data should only be from the subset with few or no missing values.
Even though you've finished cleaning up the general population demographics data, it's important to look ahead to the future and realize that you'll need to perform the same cleaning steps on the customer demographics data. In this substep, complete the function below to execute the main feature selection, encoding, and re-engineering steps you performed above. Then, when it comes to looking at the customer data in Step 3, you can just run this function on that DataFrame to get the trimmed dataset in a single step.
def clean_data(df, feat_info):
"""
Perform feature trimming, re-encoding, and engineering for demographics
data
INPUT: Demographics DataFrame
OUTPUT: Trimmed and cleaned demographics DataFrame
"""
# Identify missing or unknown data values and convert them to NaNs.
df.loc[df['CAMEO_DEUG_2015'] == 'X'] = np.nan
df.loc[df['CAMEO_DEU_2015'] == 'XX'] = np.nan
df.loc[df['CAMEO_INTL_2015'] == 'XX'] = np.nan
# Now convert two of the columns to numeric since strings are gone, keep _DEU_ as string
df['CAMEO_DEUG_2015'] = pd.to_numeric(df['CAMEO_DEUG_2015'],
errors='coerce')
df['CAMEO_INTL_2015'] = pd.to_numeric(df['CAMEO_INTL_2015'],
errors='coerce')
# Iterate over the feat_info df - this is not the most efficient
# way to do it, and I'd like to try to handle the string values
# in one single for-loop as an improvement
for index, row in feat_info.iterrows():
attr = row['attribute']
# Strip out the brackets and separate on comma
values = row['missing_or_unknown'].replace('[',
'').replace(']',
'').split(',')
for val in values:
# We still have strings in here (X / XX) so ignore them
if val not in ['', 'X', 'XX']:
val = int(val)
df.loc[df[attr] == val, attr] = np.nan
# Create new one-hot encoded decade columns
df['DECADE_40s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 1
and x <= 2 else 0)
df['DECADE_50s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 3
and x <= 4 else 0)
df['DECADE_60s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 5
and x <= 7 else 0)
df['DECADE_70s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 8
and x <= 9 else 0)
df['DECADE_80s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 10
and x <= 13 else 0)
df['DECADE_90s'] = df['PRAEGENDE_JUGENDJAHRE'].apply(lambda x: 1 if x >= 14
and x <= 15 else 0)
# Create new one-hot encoded movement columns
df['MOVEMENT_MAINSTREAM'] = df['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x in [1, 3, 5, 8, 10, 12, 14] else 0)
df['MOVEMENT_AVANTGARDE'] = df['PRAEGENDE_JUGENDJAHRE'].apply(
lambda x: 1 if x in [2, 4, 6, 7, 9, 11, 13, 15] else 0)
# Drop the rows with missing values
df = df.query('HEALTH_TYP != "NaN"')
# Split CAMEO_WEALTH into two columns with 10s and 1s values
df['CAMEO_WEALTH'], df['CAMEO_LIFESTAGE'] = divmod(
pd.to_numeric(df['CAMEO_INTL_2015'], downcast='integer'), 10)
df = pd.get_dummies(
df,
columns=[
'ANREDE_KZ', 'CJT_GESAMTTYP', 'FINANZTYP', 'GFK_URLAUBERTYP',
'GREEN_AVANTGARDE', 'LP_FAMILIE_FEIN', 'LP_FAMILIE_GROB',
'LP_STATUS_FEIN', 'LP_STATUS_GROB', 'NATIONALITAET_KZ',
'SHOPPER_TYP', 'SOHO_KZ', 'VERS_TYP', 'ZABEOTYP', 'GEBAEUDETYP',
'OST_WEST_KZ', 'CAMEO_DEUG_2015', 'CAMEO_DEU_2015', 'CAMEO_WEALTH',
'CAMEO_LIFESTAGE'
],
prefix_sep='_')
# Drop unneeded columns
df = df.drop(columns=[
'TITEL_KZ', 'AGER_TYP', 'KK_KUNDENTYP', 'KBA05_BAUMAX', 'GEBURTSJAHR',
'CAMEO_INTL_2015', 'PRAEGENDE_JUGENDJAHRE'
])
return df
azdias = pd.read_csv('Udacity_AZDIAS_Subset.csv', sep=';')
azdias = clean_data(azdias, feat_info)
# Because I optimized the function, our columns will be in different orders
# reorder them alphabetically so we can compare them properly
df_subset = df_subset.reindex(sorted(df_subset.columns), axis=1)
azdias = azdias.reindex(sorted(azdias.columns), axis=1)
# Quick test to ensure our dataframes match, compare the first 100 rows
# of each. We expect no output if they match perfectly.
pd.concat([df_subset.head(100),
azdias.head(100)]).drop_duplicates(keep=False)
azdias.shape
df_subset.shape
Great! So now we know our function will apply the same the exact same transformations as we did above. We'll use azdias as our cleaned version of the demographics dataframe going into section 2.
Before we apply dimensionality reduction techniques to the data, we need to perform feature scaling so that the principal component vectors are not influenced by the natural differences in scale for features. Starting from this part of the project, you'll want to keep an eye on the API reference page for sklearn to help you navigate to all of the classes and functions that you'll need. In this substep, you'll need to check the following:
.fit_transform() method to both fit a procedure to the data as well as apply the transformation to the data at the same time. Don't forget to keep the fit sklearn objects handy, since you'll be applying them to the customer demographics data towards the end of the project.# If you've not yet cleaned the dataset of all NaN values, then investigate and
# do that now.
azdias.isnull().sum().sort_values(ascending=False).head(40)
I've confirmed that none of the columns with NaNs are categorical. So we will replace NaNs with median values.
# I'm going to replace missing values with the median for these columns
# that contain missing values
impute = SimpleImputer(missing_values=np.nan, strategy='median')
azdias[[
'ALTER_HH', 'KKK', 'REGIOTYP', 'W_KEIT_KIND_HH', 'KBA05_ANTG2',
'KBA05_ANTG3', 'KBA05_ANTG4', 'KBA05_GBZ', 'MOBI_REGIO', 'KBA05_ANTG1',
'PLZ8_BAUMAX', 'PLZ8_ANTG2', 'PLZ8_HHZ', 'PLZ8_GBZ', 'PLZ8_ANTG4',
'PLZ8_ANTG3', 'PLZ8_ANTG1', 'KBA13_ANZAHL_PKW', 'LP_LEBENSPHASE_FEIN',
'LP_LEBENSPHASE_GROB', 'ANZ_HAUSHALTE_AKTIV', 'RELAT_AB', 'ARBEIT',
'ORTSGR_KLS9', 'ANZ_HH_TITEL', 'EWDICHTE', 'BALLRAUM', 'INNENSTADT',
'GEBAEUDETYP_RASTER', 'WOHNLAGE', 'MIN_GEBAEUDEJAHR', 'HH_EINKOMMEN_SCORE',
'RETOURTYP_BK_S', 'ONLINE_AFFINITAET', 'KONSUMNAEHE'
]] = impute.fit_transform(azdias[[
'ALTER_HH', 'KKK', 'REGIOTYP', 'W_KEIT_KIND_HH', 'KBA05_ANTG2',
'KBA05_ANTG3', 'KBA05_ANTG4', 'KBA05_GBZ', 'MOBI_REGIO', 'KBA05_ANTG1',
'PLZ8_BAUMAX', 'PLZ8_ANTG2', 'PLZ8_HHZ', 'PLZ8_GBZ', 'PLZ8_ANTG4',
'PLZ8_ANTG3', 'PLZ8_ANTG1', 'KBA13_ANZAHL_PKW', 'LP_LEBENSPHASE_FEIN',
'LP_LEBENSPHASE_GROB', 'ANZ_HAUSHALTE_AKTIV', 'RELAT_AB', 'ARBEIT',
'ORTSGR_KLS9', 'ANZ_HH_TITEL', 'EWDICHTE', 'BALLRAUM', 'INNENSTADT',
'GEBAEUDETYP_RASTER', 'WOHNLAGE', 'MIN_GEBAEUDEJAHR', 'HH_EINKOMMEN_SCORE',
'RETOURTYP_BK_S', 'ONLINE_AFFINITAET', 'KONSUMNAEHE'
]])
# Apply feature scaling to the general population demographics data.
scaler = preprocessing.StandardScaler()
azdias_scaled = pd.DataFrame(scaler.fit_transform(azdias))
# Add column names again
azdias_scaled.columns = azdias.columns
azdias_scaled.index = azdias.index
azdias_scaled = pd.DataFrame(azdias_scaled, columns=list(azdias))
# Let's take a look - I'm expecting stdevs of 1.0 here
azdias_scaled.describe()
I found this section to be one of the most challenging in the project since it required me to save and restore the column names and index, and that wasn't obvious to me at first. I scaled the values using StandardScaler() and then verified that it worked as expected by checking for standard deviation values of 1.
On your scaled data, you are now ready to apply dimensionality reduction techniques.
plot() function. Based on what you find, select a value for the number of transformed features you'll retain for the clustering part of the project.azdias_scaled.shape
# Apply PCA to the data.
pca = PCA()
pca.fit_transform(azdias_scaled)
# Investigate the variance accounted for by each principal component.
def scree_plot(pca):
'''
Creates a scree plot associated with the principal components
INPUT: pca - the result of instantiation of PCA in scikit learn
OUTPUT:
None
'''
num_components = len(pca.explained_variance_ratio_)
ind = np.arange(num_components)
vals = pca.explained_variance_ratio_
ax1 = plt.subplot(111)
cumvals = np.cumsum(vals)
ax1.bar(ind, vals)
ax2 = ax1.twinx()
ax2.plot(ind, cumvals, color='r')
ax1.xaxis.set_tick_params(width=0)
ax1.yaxis.set_tick_params(width=2, length=12)
ax1.set_xlabel("Principal Component")
ax1.set_ylabel("Variance Explained (%)")
ax2.set_ylabel("Total Variance Explained (%)")
ax1.grid(None)
plt.title('Explained Variance Per Principal Component')
scree_plot(pca)
We can see from the plot that 80% of the variance is explained by fewer than 100 components. I estimated the cutoff to be approximately 85 components.
# Re-apply PCA to the data while selecting for number of components to retain.
# Approximately 85 components explain 80% of the variance
pca_85 = PCA(n_components=85)
azdias_pca = pca_85.fit_transform(azdias_scaled)
scree_plot(pca_85)
After re-applying PCA with 85 components, I can see that my estimate was quite close - these 85 components explain almost 80% of the variance.
# I borrowed this function from the practice project helper functions
def pca_results(full_dataset, pca):
'''
Create a DataFrame of the PCA results
Includes dimension feature weights and explained variance
Visualizes the PCA results
'''
# Dimension indexing
dimensions = dimensions = ['Dimension {}'.format(
i) for i in range(1, len(pca.components_)+1)]
# PCA components
components = pd.DataFrame(
np.round(pca.components_, 4), columns=full_dataset.keys())
components.index = dimensions
# PCA explained variance
ratios = pca.explained_variance_ratio_.reshape(len(pca.components_), 1)
variance_ratios = pd.DataFrame(
np.round(ratios, 4), columns=['Explained Variance'])
variance_ratios.index = dimensions
# Create a bar plot visualization
fig, ax = plt.subplots(figsize=(14, 8))
# Plot the feature weights as a function of the components
components.plot(ax=ax, kind='bar')
ax.set_ylabel("Feature Weights")
ax.set_xticklabels(dimensions, rotation=0)
# Display the explained variance ratios
for i, ev in enumerate(pca.explained_variance_ratio_):
ax.text(i-0.40, ax.get_ylim()[1] + 0.05,
"Explained Variance\n %.4f" % (ev))
# Return a concatenated DataFrame
return pd.concat([variance_ratios, components], axis=1)
pca_results(azdias_scaled, pca_85)
I reduced the PCA to 85 components based on the 80-20 rule: they explained 80% of the variation.
Now that we have our transformed principal components, it's a nice idea to check out the weight of each variable on the first few components to see if they can be interpreted in some fashion.
As a reminder, each principal component is a unit vector that points in the direction of highest variance (after accounting for the variance captured by earlier principal components). The further a weight is from zero, the more the principal component is in the direction of the corresponding feature. If two features have large weights of the same sign (both positive or both negative), then increases in one tend expect to be associated with increases in the other. To contrast, features with different signs can be expected to show a negative correlation: increases in one variable should result in a decrease in the other.
# Map weights for the first principal component to corresponding feature names
# and then print the linked values, sorted by weight.
# HINT: Try defining a function here or in a new cell that you can reuse in the
# other cells.
# I borrowed this function from the helper functions used in the practice projects
def pca_weight(pca, df, i):
'''
INPUT:
pca - the result of instantiation of PCA in scikit learn
df - associated dataframe
i - component reference (zero-based)
OUTPUT:
df - dataframe of linked values, sorted by weight
'''
df = pd.DataFrame(pca.components_, columns=list(df.columns))
weights = df.iloc[i].sort_values(ascending=False)
return weights
pca_wgt_0 = pca_weight(pca_85, azdias_scaled, 0)
print(pca_wgt_0)
# Map weights for the second principal component to corresponding feature names
# and then print the linked values, sorted by weight.
pca_wgt_1 = pca_weight(pca_85, azdias_scaled, 1)
print(pca_wgt_1)
# Map weights for the third principal component to corresponding feature names
# and then print the linked values, sorted by weight.
pca_wgt_2 = pca_weight(pca_85, azdias_scaled, 2)
print(pca_wgt_2)
Principal Component 0:
LP_STATUS_GROB_1.0 (single low-income and average earners of younger age) and CAMEO_WEALTH_5.0 (Poorer Households) as well as several PLZ8* household macrocell features. That implies that this component consists of lower-income households and the cluster is comprised of neighborhoods with certain types of households. The strongest negative feature weights are MOBI_REGIO and KBA05_ANTG1 which add additional neighborhood and mobility aspects to this cluster.Principal Component 1:
ALTERSKATEGORIE_GROB (older estimated age) and FINANZ_VORSORGER (lower financial preparedness) which implies the individuals in this cluster are older and less financially well-off. The strongest negative feature weights are SEMIO_REL and FINANZ_SPARER which indicate this cluster also has something to do with religious affinity and money saving.Principal Component 2:
ANREDE_KZ_1.0 (male), SEMIO_VERT (dreamful personality), SEMIO_FAM (family-minded) and SEMIO_SOZ (socially-minded). That implies this cluster has something to do with male gender and personality attitudes. The strongest negative weights are ANREDE_KZ_2.0 (female), SEMIO_DOM (dominant-minded) and SEMIO_KAEM (combative attitude) which further reinforce the gender and personality aspects of this cluster.You've assessed and cleaned the demographics data, then scaled and transformed them. Now, it's time to see how the data clusters in the principal components space. In this substep, you will apply k-means clustering to the dataset and use the average within-cluster distances from each point to their assigned cluster's centroid to decide on a number of clusters to keep.
.score() method might be useful here, but note that in sklearn, scores tend to be defined so that larger is better. Try applying it to a small, toy dataset, or use an internet search to help your understanding.def plot_data(data, labels):
'''
Plot data with colors associated with labels
'''
fig = plt.figure();
ax = Axes3D(fig)
ax.scatter(data[:, 0], data[:, 1], data[:, 2], c=labels, cmap='tab10');
# Over a number of different cluster counts...
kmeans = KMeans(n_clusters=3)
# run k-means clustering on the data and...
model = kmeans.fit(azdias_pca)
azdias_predict = model.predict(azdias_pca)
plot_data(azdias_pca, azdias_predict)
# compute the average within-cluster distances.
# Investigate the change in within-cluster distance across number of clusters.
# HINT: Use matplotlib's plot function to visualize this relationship.
# Borrowed from the practice project helper functions
def get_kmeans_score(data, center):
'''
returns the kmeans score regarding SSE for points to centers
INPUT:
data - the dataset you want to fit kmeans to
center - the number of centers you want (the k value)
OUTPUT:
score - the SSE score for the kmeans model fit to the data
'''
#instantiate kmeans
kmeans = KMeans(n_clusters=center)
# Then fit the model to your data using the fit method
model = kmeans.fit(data)
# Obtain a score related to the model fit
score = np.abs(model.score(data))
return score
scores = []
centers = list(range(1,21))
for center in centers:
scores.append(get_kmeans_score(azdias_pca, center))
plt.plot(centers, scores, linestyle='--', marker='o')
plt.xlabel('K')
plt.xticks(centers)
plt.ylabel('SSE')
plt.title('SSE vs. K')
plt.show()
# Re-fit the k-means model with the selected number of clusters and obtain
# cluster predictions for the general population demographics data.
kmeans = KMeans(n_clusters=11)
# run k-means clustering on the data and...
model = kmeans.fit(azdias_pca)
azdias_predict = model.predict(azdias_pca)
plot_data(azdias_pca, azdias_predict)
The elbow of the scree plot is at approximately 11 so I selected that as the number of clusters in my k-means model. We can see a neat 3D visual representation of some of those clusters above. I saw some, because obviously several clusters will be hidden from any direction in a 3D view.
Now that you have clusters and cluster centers for the general population, it's time to see how the customer data maps on to those clusters. Take care to not confuse this for re-fitting all of the models to the customer data. Instead, you're going to use the fits from the general population to clean, transform, and cluster the customer data. In the last step of the project, you will interpret how the general population fits apply to the customer data.
;) delimited.clean_data() function you created earlier. (You can assume that the customer demographics data has similar meaning behind missing data patterns as the general demographics data.).fit() or .fit_transform() method to re-fit the old objects, nor should you be creating new sklearn objects! Carry the data through the feature scaling, PCA, and clustering steps, obtaining cluster assignments for all of the data in the customer demographics data.# Load in the customer demographics data.
customers = pd.read_csv('Udacity_CUSTOMERS_Subset.csv', sep=';')
# Clean the data
customers_cleaned = clean_data(customers, feat_info)
customers_cleaned.shape
# We're missing one column, figure out what it is
set(azdias_scaled.columns).difference(customers_cleaned.columns)
# It's a one-hot encoded column that's missing, so we can safely set it to 0
customers_cleaned['GEBAEUDETYP_5.0'] = 0
# Replace missing values with median
customers_cleaned[[
'ALTER_HH', 'KKK', 'REGIOTYP', 'W_KEIT_KIND_HH', 'KBA05_ANTG2',
'KBA05_ANTG3', 'KBA05_ANTG4', 'KBA05_GBZ', 'MOBI_REGIO', 'KBA05_ANTG1',
'PLZ8_BAUMAX', 'PLZ8_ANTG2', 'PLZ8_HHZ', 'PLZ8_GBZ', 'PLZ8_ANTG4',
'PLZ8_ANTG3', 'PLZ8_ANTG1', 'KBA13_ANZAHL_PKW', 'LP_LEBENSPHASE_FEIN',
'LP_LEBENSPHASE_GROB', 'ANZ_HAUSHALTE_AKTIV', 'RELAT_AB', 'ARBEIT',
'ORTSGR_KLS9', 'ANZ_HH_TITEL', 'EWDICHTE', 'BALLRAUM', 'INNENSTADT',
'GEBAEUDETYP_RASTER', 'WOHNLAGE', 'MIN_GEBAEUDEJAHR', 'HH_EINKOMMEN_SCORE',
'RETOURTYP_BK_S', 'ONLINE_AFFINITAET', 'KONSUMNAEHE'
]] = impute.fit_transform(customers_cleaned[[
'ALTER_HH', 'KKK', 'REGIOTYP', 'W_KEIT_KIND_HH', 'KBA05_ANTG2',
'KBA05_ANTG3', 'KBA05_ANTG4', 'KBA05_GBZ', 'MOBI_REGIO', 'KBA05_ANTG1',
'PLZ8_BAUMAX', 'PLZ8_ANTG2', 'PLZ8_HHZ', 'PLZ8_GBZ', 'PLZ8_ANTG4',
'PLZ8_ANTG3', 'PLZ8_ANTG1', 'KBA13_ANZAHL_PKW', 'LP_LEBENSPHASE_FEIN',
'LP_LEBENSPHASE_GROB', 'ANZ_HAUSHALTE_AKTIV', 'RELAT_AB', 'ARBEIT',
'ORTSGR_KLS9', 'ANZ_HH_TITEL', 'EWDICHTE', 'BALLRAUM', 'INNENSTADT',
'GEBAEUDETYP_RASTER', 'WOHNLAGE', 'MIN_GEBAEUDEJAHR', 'HH_EINKOMMEN_SCORE',
'RETOURTYP_BK_S', 'ONLINE_AFFINITAET', 'KONSUMNAEHE'
]])
customers_scaled = pd.DataFrame(scaler.fit_transform(customers_cleaned))
customers_scaled.columns = customers_cleaned.columns
customers_scaled.index = customers_cleaned.index
customers_scaled = pd.DataFrame(customers_scaled, columns=list(customers_cleaned))
# Re-fit the k-means model with the selected number of clusters and obtain
# cluster predictions for the general population demographics data.
# run k-means clustering on the data and...
customers_pca = pca_85.transform(customers_scaled)
customers_predict = model.predict(customers_pca)
plot_data(customers_pca, customers_predict)
At this point, you have clustered data based on demographics of the general population of Germany, and seen how the customer data for a mail-order sales company maps onto those demographic clusters. In this final substep, you will compare the two cluster distributions to see where the strongest customer base for the company is.
Consider the proportion of persons in each cluster for the general population, and the proportions for the customers. If we think the company's customer base to be universal, then the cluster assignment proportions should be fairly similar between the two. If there are only particular segments of the population that are interested in the company's products, then we should see a mismatch from one to the other. If there is a higher proportion of persons in a cluster for the customer data compared to the general population (e.g. 5% of persons are assigned to a cluster for the general population, but 15% of the customer data is closest to that cluster's centroid) then that suggests the people in that cluster to be a target audience for the company. On the other hand, the proportion of the data in a cluster being larger in the general population than the customer data (e.g. only 2% of customers closest to a population centroid that captures 6% of the data) suggests that group of persons to be outside of the target demographics.
Take a look at the following points in this step:
countplot() or barplot() function could be handy..inverse_transform() method of the PCA and StandardScaler objects to transform centroids back to the original data space and interpret the retrieved values directly.# Compare the proportion of data in each cluster for the customer data to the
# proportion of data in each cluster for the general population.
figure, axs = plt.subplots(nrows=1, ncols=2)
figure.subplots_adjust(hspace=1, wspace=.3)
sns.countplot(customers_predict, ax=axs[0])
axs[0].set_title('Customer Clusters')
sns.countplot(azdias_predict, ax=axs[1])
axs[1].set_title('General Clusters')
# What kinds of people are part of a cluster that is overrepresented in the
# customer data compared to the general population?
# Cluster 6 is overrepresented
centroid_6 = scaler.inverse_transform(pca_85.inverse_transform(model.cluster_centers_[6]))
overrepresented_6 = pd.Series(data = centroid_6, index = customers_scaled.columns)
pca_wgt_6 = pca_weight(pca_85, customers_scaled, 6)
print(pca_wgt_6)
cluster_specs = pd.DataFrame(scaler.inverse_transform(pca_85.inverse_transform(
model.cluster_centers_)), columns=customers_cleaned.columns)
cluster_6 = cluster_specs.iloc[6]
pd.DataFrame(dict(cluster_6=cluster_6, pca_wgt_6=pca_wgt_6)
).reset_index().sort_values(by='pca_wgt_6', ascending=False)
This cluster is primarily urban working class and upper-class women in their 30s. They show a propensity for online ordering.
STRONGEST POSITIVE FEATURE WEIGHTS:
ANREDE_KZ_2.0 Female
SEMIO_REL Religious: high affinity
SEMIO_MAT Materialistic: average to high affinity
CAMEO_DEU_2015_4A Family starter
CJT_GESAMTTYP_1.0 Advertising- and Consumptionminimalist
MIN_GEBAEUDEJAHR First year building mentioned: 1993
SEMIO_FAM Family-minded: average affinity
KBA05_GBZ 5-16 to 17-22 buildings in microcell
CAMEO_DEUG_2015_9.0 Urban working class
WOHNDAUER_2008 Length of residence 7-10 years
CAMEO_DEUG_2015_1.0 Upper class
DECADE_80s Decade of youth: 1980s
STRONGEST NEGATIVE FEATURE WEIGHTS:
ALTER_HH Birthdate head of household: 1955-01-01 to 1959-12-31
CAMEO_DEU_2015_3D Secure Retirement
SEMIO_SOZ Socially-minded: average affinity
ANREDE_KZ_1.0 Male
# What kinds of people are part of a cluster that is underrepresented in the
# customer data compared to the general population?
# Cluster 1 is underrepresented
centroid_1 = scaler.inverse_transform(pca_85.inverse_transform(model.cluster_centers_[1]))
underrepresented_c = pd.Series(data = centroid_1, index = customers_cleaned.columns)
pca_wgt_1 = pca_weight(pca_85, customers_scaled, 1)
print(pca_wgt_1)
cluster_1 = cluster_specs.iloc[1]
pd.DataFrame(dict(cluster_1=cluster_1, pca_wgt_1=pca_wgt_1)
).reset_index().sort_values(by='pca_wgt_1', ascending=False)
This cluster seems to represent older households (singles and families).
STRONGEST POSITIVE FEATURE WEIGHTS:
ALTERSKATEGORIE_GROB 30 - 45 years old to 46 - 60 years old
LP_FAMILIE_FEIN_2.0 Single low-income earners of middle age
CAMEO_LIFESTAGE_3.0 Families With School Age Children
CAMEO_DEU_2015_6B Petty Bourgeois
CAMEO_DEU_2015_7A Journeymen
CAMEO_DEU_2015_5F Active Retirement
CAMEO_WEALTH_5.0 Poorer Households
FINANZTYP_6.0 Inconspicuous
CJT_GESAMTTYP_5.0 Advertising- and Cross-Channel-Enthusiast
FINANZTYP_5.0 Investor
STRONGEST NEGATIVE FEATURE WEIGHTS:
CAMEO_DEU_2015_8B Young & Mobile
LP_FAMILIE_FEIN_1.0 Single
CAMEO_DEU_2015_7E Interested Retirees
GREEN_AVANTGARDE_1.0 Member of green avantgarde
We can draw some conclusions about the types of customers that are more popular with the mail-order company: they're more likely to be women, urban, in their thirties, and higher income. Based on this analysis we can imagine some specific marketing and advertising campaigns directed at these target demographics!